package application.models.models;

import java.io.IOException;
import java.lang.reflect.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.HashMap;
import java.util.Map;

import application.models.datalayer.*;
import application.models.exceptions.DataTypeException;
import application.models.exceptions.InvalidAttributeException;
import application.models.exceptions.MissingDataException;
import application.models.Observer;
import application.models.Observable;

/**
 * This class implements shared functionality among models. It is a further abstraction of the data layer.
 * 
 * Developers of subclasses of this class are required to enter the actual values of the two static fields:
 * 		mainSetIdAttribute
 * 		mainSetName
 * If the static field: joinedSetName is also entered, values of the following static fields are also required 
 * 		mainSetJoinAttribute		
 * 		joinedSetJoinAttribute
 * The class assumes these fields are valid.
 * 
 * @author Morten Therkildsen & Philip Rasmussen 
 */
public abstract class AbstractModel implements Iterable<Attribute>, Observable {

	private static Map<String, HashMap<Object, AbstractModel>> modelMap = new HashMap<String, HashMap<Object, AbstractModel>>(); // A map containing all models
	private SetDescription setDescription;
	private Map<String, Attribute> attributeMap; // A map containing all attributes for the entity
	private Set<Observer> observers; // A set containing all observers for any sub class of AbstractModel
	private boolean isDeleted;

	/**
	 * Help load the data of the entity with the passed id value into the modelMap.
	 * Subsequent calls with the same id value, returns the same object reference.
	 * 
	 * @param The id value of the entity to load	
	 * @return	The loaded entity 
	 */
	protected static AbstractModel loadHelper(Object idAttributeValue, Class<? extends AbstractModel> modelClass, SetDescription setDescription) {

		String modelName = modelClass.getName();

		/* If the model doesn't exist locally it will be created and initialized */
		if(getModel(modelName, idAttributeValue) == null){

			String mainSetName = setDescription.getMainSetName();
			String mainSetIdAttribute = setDescription.getMainSetIdAttribute();
			String mainSetJoinAttribute = setDescription.getMainSetJoinAttribute();
			String joinedSetName = setDescription.getJoinedSetName();
			String joinSetJoinAttribute = setDescription.getJoinedSetJoinAttribute();			
			DataLayerCommunicator dlComm = DataLayerCommunicator.getInstance();

			try{

				Attribute[][] mainEntities = dlComm.readFromDataLayer(mainSetName, new DataCondition[]{new DataCondition(mainSetIdAttribute, Operator.EQUALS, idAttributeValue)});
				if(mainEntities.length == 0) throw new IllegalArgumentException("The id value does not match any model in the database.");
				else{

					Attribute[] mainSetAttributes = mainEntities[0]; // We only got 1 row...
					Object setIdAttributeValue = null;
					Object mainSetJoinAttributeValue = null;

					/* Loop over attributes and find the values of the variables above */

					for(Attribute mainEntityAttribute : mainSetAttributes){

						if(mainEntityAttribute.getName().equals(mainSetIdAttribute)){

							setIdAttributeValue = mainEntityAttribute.getValue();
						} else if(mainEntityAttribute.getName().equals(mainSetJoinAttribute)){

							mainSetJoinAttributeValue = mainEntityAttribute.getValue();
						}
					}

					try {

						AbstractModel model = modelClass.getConstructor(Class.forName("java.lang.Object"), Class.forName("java.lang.Object")).newInstance(setIdAttributeValue, mainSetJoinAttributeValue);	
						for(Attribute mainAttribute : mainSetAttributes){

							if(mainAttribute.getName().equals(mainSetIdAttribute)) continue; // Don't try to add the mainSetIdAttribute
							if(mainAttribute.getName().equals(mainSetJoinAttribute)) continue; // Don't try to add the mainSetJoinAttribute
							model.setAttribute(mainAttribute.getName(), mainAttribute.getValue());
						}						
						if(joinedSetName != null){

							Attribute[][] joinEntities = dlComm.readFromDataLayer(joinedSetName, new DataCondition[]{new DataCondition(joinSetJoinAttribute, Operator.EQUALS, mainSetJoinAttributeValue)});
							if(joinEntities.length == 0) throw new IllegalArgumentException("The value of the joined sets id attribute does not match any entity in the database.");
							else {

								for(Attribute joinAttribute : joinEntities[0]){

									if(joinAttribute.getName().equals(joinSetJoinAttribute)) continue; // Don't try to add the joinSetJoinAttribute
									model.setAttribute(joinAttribute.getName(), joinAttribute.getValue());

								}								
							}	
						}	

						addToModelMap(model);

					} catch(InstantiationException e){ throw new RuntimeException("Failed to load model: The model cannot be created.", e);
					} catch(ClassNotFoundException e){ throw new RuntimeException("Unexpected logic error: The class java.lang.Object cannot be found.", e);
					} catch(InvalidAttributeException e){ throw new RuntimeException("Unexpected logic error: One of the attributes are not applicable to the model.", e);
					} catch (SecurityException e) { throw new RuntimeException("The constructor cannot be accessed.", e);
					} catch (IllegalAccessException e) { throw new RuntimeException("The constructor cannot be accessed.", e);
					} catch(InvocationTargetException e){ throw new RuntimeException("Failed to load model.", e);
					} catch(NoSuchMethodException e){ throw new RuntimeException("Failed to load model.", e);
					}
				}
			} catch(IOException e){ throw new RuntimeException("Retrieval of data from the data layer failed", e); 
			}
		} 	
		return getModel(modelName, idAttributeValue);
	}

	/**
	 * Read values taken from the data layer and allocates them to fitting variables. This constructor can only be called form subclasses of AbstractModel
	 * 
	 * @param mainSetIdAttributeValue			The value of the main sets id
	 * @param joinedSetJoinAttributeValue		The value of the joined sets join attribute
	 */
	public AbstractModel(Object mainSetIdAttributeValue, Object mainSetJoinAttributeValue, SetDescription setDescriptionObject){
		
		attributeMap = new HashMap<String, Attribute>();
		observers = new HashSet<Observer>();
		isDeleted = false;
		setDescription = setDescriptionObject;
		DataLayerCommunicator dlComm = DataLayerCommunicator.getInstance();

		/* Load the attribute objects that fits the model into the attributeMap */
		try{

			Attribute[] mainSetAttributes = dlComm.getDataSetDescription(setDescriptionObject.getMainSetName());
			for(Attribute mainSetAttribute : mainSetAttributes){

				attributeMap.put(mainSetAttribute.getName(), mainSetAttribute);
			}	
			
			try{
				
				attributeMap.get(setDescriptionObject.getMainSetIdAttribute()).setValue(mainSetIdAttributeValue); 
			} catch(DataTypeException e){ throw new RuntimeException("The data type of the value of mainSetIdAttribute is not supported");
			}	

		}catch(IOException e){ throw new RuntimeException("Retrieval of attributes from main set in data layer failed.", e);		
		}catch(RuntimeException e){ throw new RuntimeException("Connecting to the data layer failed during runtime.", e);
		}

		if(setDescriptionObject.getJoinedSetName() != null){

			try{

				Attribute[] joinSetAttributes = dlComm.getDataSetDescription(setDescriptionObject.getJoinedSetName());

				for(Attribute joinSetAttribute : joinSetAttributes){

					/* To avoid overwriting existing attributes*/
					if(!attributeMap.containsKey(joinSetAttribute.getName())){
						attributeMap.put(joinSetAttribute.getName(), joinSetAttribute);
					}
				}
				
				try{
					attributeMap.get(setDescriptionObject.getMainSetJoinAttribute()).setValue(mainSetJoinAttributeValue);
				} catch(DataTypeException e){ throw new RuntimeException("The data type of the value of mainSetJoinAttribute is not supported");
				}
			} catch(IOException e){ throw new RuntimeException("Retrieval of attributes from joined set in data layer failed.", e);
			} catch(RuntimeException e){ throw new RuntimeException("Connecting to the data layer failed during runtime.", e);
			}
		}
	}

	/**
	 * Saves the model in the data layer, creating it or updating it
	 * @throws MissingDataException If there is mandatory data missing
	 */
	public void save() throws MissingDataException{

		ArrayList<Attribute> mainAttributes = new ArrayList<Attribute>();
		ArrayList<Attribute> joinAttributes = new ArrayList<Attribute>();
		DataLayerCommunicator dlComm = DataLayerCommunicator.getInstance();
		Attribute idAttribute;

		// Validate attributes
		for(Attribute attribute : attributeMap.values()){          
			
			if(
					attribute.getName().equals(setDescription.getMainSetIdAttribute()) ||
					attribute.getName().equals(setDescription.getMainSetJoinAttribute())
			) continue; // These will be added later if required but not set
			if(!mandatoryAttributeValidation(attribute)) throw new MissingDataException("A mandatory attribute (\""+attribute.getName()+"\") had null value");
		}		
		
		/* If there is a set joined with the main set */
		if(setDescription.getJoinedSetName() != null){

			/* Write the attributes of the joined set to the data layer */
			try{

				/* Find attributes matching the join set */  
				for(Attribute attribute : attributeMap.values()) if(attribute.getDataSetOwner().equalsIgnoreCase(setDescription.getJoinedSetName())) joinAttributes.add(attribute);

				idAttribute = dlComm.writeToDataLayer(setDescription.getJoinedSetName(), joinAttributes.toArray(new Attribute[0]));
				
				try{
					attributeMap.get(setDescription.getMainSetJoinAttribute()).setValue(idAttribute.getValue());
				} catch(DataTypeException e){ throw new RuntimeException("The data type of the value of idAttribute is not supported");
				}
			} catch(IOException e){	throw new RuntimeException("Writing attributes to joined set in data layer failed.", e);
			}	
		}

		/* Write the attributes of the main set to the data layer */
		try{
			/* Find attributes matching the main set */                  
			for(Attribute attribute : attributeMap.values()) if(attribute.getDataSetOwner().equalsIgnoreCase(setDescription.getMainSetName())) mainAttributes.add(attribute);

			idAttribute = dlComm.writeToDataLayer(setDescription.getMainSetName(), mainAttributes.toArray(new Attribute[0]));
		
			try{
				attributeMap.get(setDescription.getMainSetIdAttribute()).setValue(idAttribute.getValue());
			} catch(DataTypeException e){ throw new RuntimeException("The data type of the value of idAttribute is not supported");
			}
		} catch(IOException e){

			throw new RuntimeException("Writing attributes to main set in data layer failed", e);
		}

		addToModelMap(this);
		isDeleted = false;	// If the entity has been deleted, and the save() call is used on it, it will be 'reloaded' into the data layer
		notifyObservers(); // Tell people we changed state permanently =)
	}

	/**
	 * Delete the entity from the data layer. The entity object still exists locally, and can be 'reloaded' into the data layer with same or different attribute values by using the save() call.
	 * This makes it possible to delete an entity from the data layer, modify it's field values isolated from the data layer, and 'reload' it into the data layer with the modified values.
	 * 
	 * If the entity's model set is joined with another set, the "matching" joined set attributes are also deleted. 
	 * This happens automatically in SQL, but we've added the functionality for increased security - it does not hurt :)
	 * 
	 * @throws IOException if the operation has failed.
	 */
	public void delete(){

		// Test that we are saved - if not, just return
		try {getId();}
		catch(NullPointerException e){return;}
		
		DataLayerCommunicator dlComm = DataLayerCommunicator.getInstance();

		try{

			dlComm.deleteFromDataLayer(setDescription.getMainSetName(), new DataCondition[]{new DataCondition(setDescription.getMainSetIdAttribute(), Operator.EQUALS, getId())});

		}catch(IOException e){

			throw new RuntimeException("Attempted deletion of main set attributes in the data layer failed.", e);
		}

		if(setDescription.getJoinedSetName() != null){

			try{

				dlComm.deleteFromDataLayer(setDescription.getJoinedSetName(), new DataCondition[]{new DataCondition(setDescription.getJoinedSetJoinAttribute(), Operator.EQUALS, attributeMap.get(setDescription.getMainSetJoinAttribute()).getValue())});	
			} catch(IOException e){

				throw new RuntimeException("Attempted deletion of joined set attributes in the data layer failed.", e);
			}
		}

		modelMap.remove(getId());

		isDeleted = true;
		notifyObservers(); // We are officially deleted now!
	}

	/**
	 * Adds a model to the model map
	 * @param The model that is to be added to the modelMap
	 */
	private static void addToModelMap(AbstractModel model){

		String modelType = model.getClass().getName();

		if(!modelMap.containsKey(modelType)) modelMap.put(modelType, new HashMap<Object, AbstractModel>());

		modelMap.get(modelType).put(model.getId(), model);
	}

	/**
	 * Returns the model that contains the id value
	 * @param modelName The name of the model 
	 * @param id The value of the model's id
	 * @return The 
	 */
	private static AbstractModel getModel(String modelName, Object id){

		if(modelMap.containsKey(modelName)) return modelMap.get(modelName).get(id);
		else return null;
	}

	/**
	 * Sets the value of an entity attribute. It is ensured that no critical attribute values can be modified.
	 * 
	 * @param name	The name of the attribute to set
	 * @param value The value the attribute should be set to
	 * 
	 * @throws InvalidAttributeException if the entity has no attribute with the passed name, the value is of wrong type, or the attribute cannot be set
	 */
	public void setAttribute(String name, Object value) throws InvalidAttributeException{

		if(!attributeMap.containsKey(name) || name.equals(setDescription.getMainSetIdAttribute()) || name.equals(setDescription.getMainSetJoinAttribute())){
			throw new InvalidAttributeException("The model has no attribute with the name '"+name+"'.");
		} else {

			/* Ensures that the attribute value is valid in the data layer */
			if(!attributeMap.get(name).supportsValue(value) || !attributeValueValidation(value)) throw new InvalidAttributeException("The value is invalid for the attribute");

			try{
				attributeMap.get(name).setValue(value);
			} catch(DataTypeException e){ throw new RuntimeException("Unexpected logic failure");
			}
		}		
	}
		

	/**
	 * Returns the value of an entity's attribute. Doesn't work for an entity's id attribute.
	 * 
	 * @param name	The name of the attribute
	 * @return		The value of the attribute
	 * 
	 * @throws InvalidAttributeException if the entity has no attribute with the passed name
	 */
	public Object getAttribute(String name) throws InvalidAttributeException{

		if(!attributeMap.containsKey(name) || name.equals(setDescription.getMainSetIdAttribute()) || name.equals(setDescription.getMainSetJoinAttribute())){
			throw new InvalidAttributeException("The model has no attribute with the name '"+name+"'.");
		} 
		return attributeMap.get(name).getValue(); 
	}

	/**
	 * Returns the value of the id attribute of this entity.
	 * @return The value of the entity's id attribute.
	 */
	public int getId(){

		Integer id = (Integer) attributeMap.get(setDescription.getMainSetIdAttribute()).getValue();
		if(id == null) throw new NullPointerException("id doesn't exist");
		return id.intValue();
	}

	/**
	 * If the model entity is deleted from the data layer. This is possible to test against as the entity object still exists locally.
	 * @return True if the entity is deleted from the data layer, false if not
	 */
	public boolean isDeleted(){

		return isDeleted;
	}

	/**
	 * Registers an observer for the object
	 * An observer may only be added once - subsequent adds of the same observer are ignored
	 * 
	 * @param observer		The observer to add
	 */
	public void addObserver(Observer observer){

		if(observer == null) throw new NullPointerException("observer was null!");
		observers.add(observer);
	}

	/**
	 * Notifies all observers when called
	 */
	protected void notifyObservers(){

		for(Observer observer : observers){
			observer.notify(this);
		}
	}

	/**
	 * Removes a previously set observer from the observable object
	 * Nothing will happen if observer has not been added beforehand
	 * 
	 * @param observer		A previously added observer
	 */
	public void removeObserver(Observer observer){

		observers.remove(observer);
	}

	/**
	 * Used for iteration
	 * 
	 * @return An iterator used to iterate over an entity's attributes
	 */
	public Iterator<Attribute> iterator(){

		Map<String, Attribute> map = new HashMap<String, Attribute>(attributeMap);

		map.remove(setDescription.getMainSetIdAttribute());
		map.remove(setDescription.getMainSetJoinAttribute());
		map.remove(setDescription.getJoinedSetJoinAttribute());
		
		return map.values().iterator();
	}

	/**
	 * Validates that an attributes have met the data layer criteria
	 *  
	 * @param An attribute
	 * @return	True if validation succeeds, false if attribute has a null value
	 */
	abstract protected boolean mandatoryAttributeValidation(Attribute attribute);

	/**
	 * Validates that the object's value is valid
	 */
	abstract protected boolean attributeValueValidation(Object value);
}